Add CLI OAuth device login#1590
Conversation
Greptile SummaryThis PR adds an OAuth2 device-authorization login flow for Appwrite Cloud while preserving the existing email/password path for self-hosted instances. Token storage, auto-refresh, and server-side revocation on logout/reset are introduced across new
Confidence Score: 3/5The auth refactor introduces correct OAuth polling, MFA cleanup, and per-credential server revocation, but two important behaviors in the auth path still need attention before this is production-ready. The single-session logout path prints 'Logged out successfully' unconditionally even when server revocation fails and the local session is restored. In sdkForProject, when an OAuth access token is present but its refresh token is revoked, getValidAccessToken throws before the API-key branch is ever reached, so users who mixed OAuth login with a configured key can lose access to project commands without a clear path to recovery. templates/cli/lib/commands/generic.ts (single-session logout success message) and templates/cli/lib/sdks.ts (OAuth-throws-before-key-fallback in sdkForProject) Important Files Changed
Reviews (21): Last reviewed commit: "(chore): use published @appwrite.io/cons..." | Re-trigger Greptile |
Extract the OAuth device-login logic that landed in commands/generic.ts into a cohesive lib/auth/ layer, preserving all behavior: - lib/auth/oauth.ts: OAuth2 client factory, device-token polling, token refresh, revoke, and id_token decoding (getValidAccessToken moved here from sdks.ts; the 3x-duplicated Oauth2 client construction is unified behind createOauth2). - lib/auth/session.ts: typed session accessor, classification + current- session helpers, deleteServerSession, and a single logoutSessions() that replaces the three near-identical cleanup loops. - lib/auth/login.ts: login orchestration and flows (loginCommand, loginWithEmailPassword, loginWithOAuthDevice, switchToAccount, completeMfaLogin, getCurrentAccount). - utils.ts: endpoint classifiers placed next to isCloudHostname. - generic.ts slimmed from ~1060 to ~330 lines (command definitions only). Repointed sdks.ts/deployment.ts imports and registered the new auth files in CLI.php getFiles(). Device-login output uses process.stdout.write (matching the existing spinner idiom); dropped thin wrapper helpers.
The questionsListFactors choices loader built its console SDK with requiresAuth: false. Since the OAuth login change made sdkForConsole attach the session cookie only when requiresAuth is true, the MFA factor list was fetched as a guest and failed with 401 — blocking self-hosted email/password logins that reach MFA without --mfa from picking a factor. Use an authenticated client so the partial MFA session cookie is sent.
Add a logic-layer auth section to the CLI e2e script that asserts the pure/near-pure functions introduced by the OAuth device-login work: endpoint classification (cloud/regional/localhost/dev-override), id_token decoding, authorization-pending detection, device-token polling (success/retry/error/timeout via a fake oauth2), cached access-token reuse, session classifiers, planSessionLogout grouping, and restoreCurrentSessionFallback. Driven by AUTH_LOGIC_RESPONSES in Base.php and the three CLIBun test classes; no new runner or mock-server changes.
Add lib/flags.ts, a central registry of CLI feature flags read from env
vars via isFlagEnabled("<name>") so future flags are a one-line addition.
Route the OAuth device-login gate (APPWRITE_CLI_OAUTH_LOGIN) and the
localhost-as-Cloud dev override (APPWRITE_CLI_DEV_CLOUD_LOGIN) through it,
replacing the ad-hoc per-flag helpers in utils.ts.
OAuth device login stays off by default: Cloud endpoints use email/password
login (and the legacy-cookie migration nudges stay silent) until the flag is
enabled. Covered by the e2e auth-logic assertions.
pollForDeviceToken now honors slow_down by increasing the polling interval by 5s per RFC 8628 (it previously retried at the original interval), and treats an empty/unrecognized error body during polling as a transient pending response instead of aborting the device flow with a blank AppwriteException. Genuine terminal errors still propagate. Adds e2e coverage for the slow_down backoff and empty-body retry paths.
The CLI generator renders lib/constants.ts from constants.ts.twig (the only entry registered in CLI.php getFiles); the plain constants.ts was a stale leftover from an earlier refactor, missing CONFIG_RESOURCE_KEYS, HOMEBREW_FORMULA, UPDATE_CHECK_INTERVAL_MS, and TOP_LEVEL_RESOURCE_ARRAY_KEYS. It is never read during generation, so the generated SDK was unaffected, but building the template dir directly imported the stale file and failed. Removing it leaves a single source of truth. Generated output is unchanged.
…elf-signed legacy revoke - pollForDeviceToken now defaults to a 5s interval when the device authorization response omits one (RFC 8628 §3.5); previously an undefined interval produced NaN and busy-polled the token endpoint until expiry. - getCurrentAccount/switchToAccount no longer persist the normalized console endpoint back into the session. sdkForConsole already normalizes when building the console client, so persisting it overwrote a regional Cloud endpoint and could route later project calls to the generic Cloud host. - deleteServerSession builds the legacy client with the target session's own selfSigned setting (added to SessionData) instead of the current session's, so revoking a self-signed legacy session during OAuth migration no longer fails TLS verification and strand the session. Adds an e2e assertion for the default polling interval.
The previous default-interval guard treated interval <= 0 as omitted, so a deviceAuth with interval 0 (used to poll without delay) was forced to the 5s default and consumed the whole expiry window before the next attempt, breaking the retry/slow_down/empty-body poll paths in CI. Only non-finite (omitted) intervals now fall back to 5s; an explicit 0 is honored. Strengthens the default-interval test to assert the fallback delay actually applies.
templates/cli/lib/constants.ts is the local type-check stand-in for constants.ts.twig (tsc resolves ./constants.js to the plain .ts; the .twig is invisible to it), letting templates/cli type-check before generation. It had drifted from the twig, missing UPDATE_CHECK_INTERVAL_MS, HOMEBREW_TAP/FORMULA, CONFIG_RESOURCE_KEYS, and TOP_LEVEL_RESOURCE_ARRAY_KEYS. Restored and synced so its exported symbols match the twig. Not registered in getFiles, so generated output is unchanged (it renders from the twig).
Replace the bespoke createOauth2 helper with the shared services factory.
The OAuth token/revoke/device-authorization clients are now built with
sdkForConsole({ requiresAuth: false, endpointOverride }) wrapped by
getOauth2Service, matching how every other service client is constructed.
requiresAuth:false keeps them unauthenticated (these calls establish/refresh
a session) and avoids recursing into getValidAccessToken.
getValidAccessToken (the bearer-refresh primitive that sdkForConsole/ sdkForProject depend on) moves from auth/oauth.ts into sdks.ts where it belongs, and the shared OAUTH2_CLIENT_ID/OAUTH2_SCOPES move to constants.ts. sdks.ts no longer imports auth/oauth.ts, so auth/oauth.ts is free to build its OAuth2 clients via services.ts getOauth2Service without a circular import. Net dependency flow is now acyclic: constants/config (leaves) -> sdks -> services -> oauth -> session/login. getValidAccessToken constructs its refresh client directly (it sits below the services factory); revoke and the device flow use getOauth2Service.
…w build Drops the pkg.vc preview pin (54cebb6) for the published ^15.0.0 release now that the API surface (incl. the Usage service) is aligned in appwrite/specs. - package.json/.twig -> ^15.0.0; lockfile twigs regenerated via update-lockfiles.sh - oauth.ts: drop projectId from oauth2.revoke() (15.0.0 sets it on the client) - bunfig.toml: exempt @appwrite.io/console from the 7d minimumReleaseAge guard (first-party SDK released in lockstep with the CLI; freshly-published releases must install immediately, e.g. for the CLIBun e2e bun install) Verified against examples/cli: tsc + build:runtime clean, bun install resolves 15.0.0 under the bunfig policy.
Summary
This PR adds the new browser-based OAuth2 device login flow to the Appwrite CLI while preserving existing self-hosted email/password login and legacy cookie sessions. It also hardens session switching, logout, reset, token refresh, deployment log streaming, and local Cloud development behavior around the new auth model.
What Changed
Browser-based CLI login
/accountbefore reporting login success.slow_downresponses by increasing the polling interval.login --newfrom showing the legacy-session warning for the command that is explicitly migrating to the new flow.Backward compatibility and self-hosted login
APPWRITE_CLI_DEV_CLOUD_LOGIN=1treatslocalhost/loopback endpoints as Cloud for OAuth device login testing.Session switching and auth precedence
login --switchso selecting an existing account actually switches instead of starting a new OAuth login.Logout, reset, and server-side cleanup
client --resetcan fully clear local CLI configuration without requiring server revocation.Deployment and realtime behavior
localhost, the CLI fetches the project region in memory and formats console links likeproject-fra-<projectId>without writing region data intoappwrite.config.json.Generated CLI output
@appwrite.io/consolepackage that includes OAuth2 device authorization, token refresh, and revoke support.Testing
Commands run during this PR:
./scripts/update-lockfiles.sh cliphp example.php clicomposer lint-twigcomposer refactor:checkvendor/bin/phpunit --testsuite Unitnpm installinexamples/clinpm run build:typesinexamples/clinpm run build:runtimeinexamples/clinpm run buildinexamples/clinpm run mac-arm64inexamples/clinpx eslint lib/commands/generic.ts lib/commands/init.ts lib/sdks.ts lib/types.ts lib/commands/utils/deployment.tsinexamples/cliManual/local verification performed:
./build/appwrite-cli-darwin-arm64 login --new --endpoint "http://localhost/v1"against a local Cloud server withAPPWRITE_CLI_DEV_CLOUD_LOGIN=1.login --newno longer shows the legacy cookie-session warning for that command.project list-policies --limit 1succeeds with bearer auth against local Cloud.http://localhost/console/project-fra-...for regional local Cloud projects.Login flow examples
Browser-based OAuth2 device login (Appwrite Cloud)
When OAuth login is enabled,
appwrite loginagainst a Cloud endpoint starts the device-authorization flow: it prints a one-time code and verification URL, then polls until you approve in the browser.The access and refresh tokens are stored in the CLI preferences and the access token is refreshed automatically before authenticated calls (honoring
slow_downbackoff while polling, per RFC 8628). The refresh token is revoked onlogout.Feature flag (staged rollout)
The device flow is gated behind an opt-in environment variable, off by default, so Cloud keeps using email/password until it is explicitly enabled:
Self-hosted email/password (preserved)
Self-hosted endpoints — and Cloud while the flag is off — keep the existing email/password flow, including MFA:
When MFA is required the CLI prompts for a factor and code next (or accepts
--mfa/--codenon-interactively).Adding and switching accounts
Legacy cookie sessions
Existing cookie sessions keep working. When OAuth login is enabled, the CLI nudges you to migrate, and
login --newremoves legacy cookie sessions after the new session is verified:Sign out